package io.ebean.xtest.dbmigration;

import io.ebean.*;
import io.ebean.annotation.Platform;
import io.ebean.DatabaseBuilder;
import io.ebean.config.DatabaseConfig;
import io.ebean.config.dbplatform.DbHistorySupport;
import io.ebean.datasource.DataSourcePool;
import io.ebean.xtest.BaseTestCase;
import io.ebean.xtest.IgnorePlatform;
import misc.migration.v1_0.ETable;
import misc.migration.v1_1.EHistory;
import misc.migration.v1_1.EHistory2;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.URL;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * This testcase tries to apply the migrationtests that are genearated by {@link DbMigrationGenerateTest}.
 *
 * It does also some basic checks, if the migration is applied correctly.
 *
 * Please note, that this test requires the scripts generated by DbMigrationGenerateTest. So you may have to execute this test
 * first.
 *
 * @author Roland Praml, FOCONIS AG
 *
 */
public class DbMigrationTest extends BaseTestCase {

  private void runScript(String scriptName) {
    URL url = getClass().getResource("/migrationtest/dbmigration/" + server().platform().name().toLowerCase() + "/" + scriptName);
    assert url != null : scriptName +  " not found for platform [" + server().platform().name().toLowerCase() + "]";
    server().script().run(url, Map.of("reorgArgs", "use tempspace1 resetdictionary"));
  }

  @IgnorePlatform({
    // Yugabyte does not see column updates on table alters:
    // update table T set C = 'value'; alter table T alter column C set not null -> error column C has null values
    // do we need a commit after update?
    Platform.YUGABYTE,
  })
  @Test
  public void testRunMigration() throws IOException, SQLException {
    // Shutdown and reconnect - this prevents postgres from lock up
    ((DataSourcePool)server().dataSource()).offline();
    ((DataSourcePool)server().dataSource()).online();
    cleanup("migtest_ckey_assoc",
      "migtest_ckey_detail",
      "migtest_ckey_parent",
      "migtest_e_basic",
      "migtest_e_test_binary",
      "migtest_e_test_varchar",
      "migtest_e_test_lob",
      "migtest_e_test_json",
      "migtest_e_enum",
      "migtest_e_history",
      "migtest_e_history2",
      "migtest_e_history3",
      "migtest_e_history4",
      "migtest_e_history5",
      "migtest_e_history6",
      "migtest_e_ref",
      "migtest_e_softdelete",
      "migtest_e_user",
      "migtest_fk_cascade",
      "migtest_fk_cascade_one",
      "migtest_fk_none",
      "migtest_fk_none_via_join",
      "migtest_fk_one",
      "migtest_fk_set_null",
      "migtest_mtm_c",
      "migtest_mtm_m",
      "migtest_mtm_m_phone_numbers",
      "migtest_mtm_c_migtest_mtm_m",
      "migtest_mtm_m_migtest_mtm_c",
      "migtest_oto_child",
      "migtest_oto_master",
      "`migtest_QuOtEd`",
      "migtest_QuOtEd",
      "\"migtest_QuOtEd\"",
      "drop_main_drop_ref_many",
      "drop_ref_one_to_one",
      "drop_ref_many",
      "drop_ref_one",
      "drop_main",
      "table",
      "\"table\"",
      "`table`");

    ((DataSourcePool)server().dataSource()).offline();
    ((DataSourcePool)server().dataSource()).online();

    if (isSqlServer() || isMariaDB() || isMySql() || isHana()) {
      runScript("I__create_procs.sql");
    }

    if(isDb2()) {
      runScript("I__create_tablespaces.sql");
    }

    if (!isOracle()) {
      // oracle.getMetaData is too slow. So skip this test
      assertThat(getTables()).doesNotContain("migtest_QuOtEd");
    }
    runScript("1.0__initial.sql");
    if (!isOracle()) {
      assertThat(getTables()) // we expect exact spelling here
        .contains("migtest_QuOtEd")
        .doesNotContain("migtest_quoted")
        .doesNotContain("MIGTEST_QUOTED");
    }
    if (isClickHouse()) {
      // ClickHouse does not support transactions, so we cannot do update statements
      // Add column is also not implemented. So exit here
      return;
    } else if (isOracle() || isHana()) {
      SqlUpdate update = server().sqlUpdate("insert into migtest_e_basic (id, old_boolean, user_id) values (1, :false, 1)");
      update.setParameter("false", false);
      assertThat(server().execute(update)).isEqualTo(1);

      update = server().sqlUpdate("insert into migtest_e_basic (id, old_boolean, user_id) values (2, :true, 1)");
      update.setParameter("true", true);
      assertThat(server().execute(update)).isEqualTo(1);
    } else {
      SqlUpdate update = server().sqlUpdate("insert into migtest_e_basic (id, old_boolean, user_id) values (1, :false, 1), (2, :true, 1)");
      update.setParameter("false", false);
      update.setParameter("true", true);

      assertThat(server().execute(update)).isEqualTo(2);
    }
    testReservedKeywords();
    createHistoryEntities();
    if (isOracle()) {
      // Oracle does not like to convert varchar to integer
      // ORA-01439. "column to be modified must be empty to change datatype".
      // If the current table is not empty, you may have to create a temp-table
      // with correct data types or do it with DBMS_REDEFINITION - to get the test
      // working, we clear all data in the table
      server().sqlUpdate("delete from migtest_e_history").execute();
      server().sqlUpdate("delete from migtest_e_history4").execute();
    }

    // Run migration
    runScript("1.1.sql");
    List<SqlRow> result = server().sqlQuery("select * from migtest_e_basic order by id").findList();
    assertThat(result).hasSize(2);

    SqlRow row = result.get(0);
    assertThat(row.keySet()).contains("old_boolean", "old_boolean2");

    assertThat(row.getInteger("id")).isEqualTo(1);
    assertThat(row.getBoolean("old_boolean")).isFalse();
    assertThat(row.getBoolean("new_boolean_field")).isFalse(); // test if update old_boolean -> new_boolean_field works well

    assertThat(row.getString("new_string_field")).isEqualTo("foo'bar");
    assertThat(row.getBoolean("new_boolean_field2")).isTrue();
    //assertThat(row.getTimestamp("some_date")).isCloseTo(new Date(), 86_000); // allow 1 minute delta

    row = result.get(1);
    assertThat(row.getInteger("id")).isEqualTo(2);
    assertThat(row.getBoolean("old_boolean")).isTrue();
    assertThat(row.getBoolean("new_boolean_field")).isTrue(); // test if update old_boolean -> new_boolean_field works well

    assertThat(row.getString("new_string_field")).isEqualTo("foo'bar");
    assertThat(row.getBoolean("new_boolean_field2")).isTrue();
    //assertThat(row.getTimestamp("some_date")).isCloseTo(new Date(), 60_000); // allow 1 minute delta

    testVersioning();
    if (isSqLite()) {
      // SqLite does not support drops on columns with foreign keys, so we end with the test here.
      return;
    }
    runScript("1.2__dropsFor_1.1.sql");
    if (!isOracle()) {
      // too slow!
      assertThat(getTables()).doesNotContain("migtest_QuOtEd");
    }
    // Oracle caches the statement and does not detect schema change. It fails with
    // an ORA-01007
    result = server().sqlQuery("select * from migtest_e_basic order by id,status").findList();
    assertThat(result).hasSize(2);
    row = result.get(0);
    assertThat(row.keySet()).doesNotContain("old_boolean", "old_boolean2");

    if (isYugabyte()) {
      // there are some unsupported alter commands in 1.3 - so we exit here
      return;
    }
    runScript("1.3.sql");
    runScript("1.4__dropsFor_1.3.sql");

    // now DB structure should be the same as v1_0 - perform a diffent query.
    result = server().sqlQuery("select * from migtest_e_basic order by id,name").findList();
    assertThat(result).hasSize(2);
    row = result.get(0);
    assertThat(row.keySet()).contains("old_boolean", "old_boolean2");
  }

  // do some history tests with V1.1 models
  private void testVersioning() {
    if (isOracle()) {
      System.err.println("FIXME: Oracle history support seems to be broken");
      return;
    }
    DbHistorySupport history = server().pluginApi().databasePlatform().historySupport();
    if (history == null) {
      return;
    }
    DatabaseBuilder config = new DatabaseConfig();
    config.setName(server().name());
    config.loadFromProperties(server().pluginApi().config().getProperties());
    config.setDataSource(server().dataSource());
    config.setReadOnlyDataSource(server().readOnlyDataSource());
    config.setDdlGenerate(false);
    config.setDdlRun(false);
    config.setRegister(false);
    config.setPackages(Collections.singletonList("misc.migration.v1_1"));

    Database tmpServer = DatabaseFactory.create(config);
    try {
      EHistory hist = new misc.migration.v1_1.EHistory();
      hist.setId(2);
      hist.setTestString(42L);
      tmpServer.save(hist);
      hist = tmpServer.find(EHistory.class).where().eq("testString", 42L).findOne();
      assert hist != null;
      hist.setTestString(45L);
      tmpServer.save(hist);

      List<Version<EHistory>> versions = tmpServer.find(EHistory.class).setId(hist.getId())
          .findVersionsBetween(Timestamp.valueOf("1970-01-01 00:00:00"), Timestamp.valueOf("2100-01-01 00:00:00"));
      assertThat(versions).hasSize(2);
      assertThat(versions.get(0).getDiff().toString()).as("using platform: %s", server().platform())
        .isEqualTo("{testString=45,42}");

      EHistory2 hist2 = new misc.migration.v1_1.EHistory2();
      hist2.setId(2);
      hist2.setTestString("foo1");
      hist2.setTestString2("bar1");
      hist2.setTestString3("baz1");
      tmpServer.save(hist2);
      hist2.setTestString("foo2");
      hist2.setTestString2("bar2");
      tmpServer.save(hist2);

      List<Version<EHistory2>> versions2 = tmpServer.find(EHistory2.class).setId(hist.getId())
          .findVersionsBetween(Timestamp.valueOf("1970-01-01 00:00:00"), Timestamp.valueOf("2100-01-01 00:00:00"));
      assertThat(versions2).hasSize(2);

      // not all platforms will support history exclusions
      switch (server().platform()) {
      case H2: // Trigger ignores HistoryExclude
      case SQLSERVER17: // these DBs are 'standard based' so they also do not support HistoryExclude
      case MARIADB:
      case HANA:
      case DB2LUW:
      case DB2FORI: // not yet tested
      case DB2ZOS: // not yet tested
        assertThat(versions2.get(0).getDiff().toString()).as("using platform: %s, versions2:%s", server().platform(), versions2)
            .contains("testString=foo2,foo1")
            .contains("testString2=bar2,bar1");
        break;
      case MYSQL:
      case POSTGRES:
      case YUGABYTE:
        assertThat(versions2.get(0).getDiff().toString()).as("using platform: %s, versions2:%s", server().platform(), versions2)
            .contains("testString=foo2,foo1")
            .contains("testString2=bar2,null");
        break;
      default:
        throw new IllegalArgumentException(server().platform() + " not expected");
      }

    } finally {
      tmpServer.shutdown(false, false);
    }
  }

  // do some history tests with V1.1 models
  private void testReservedKeywords() {
    DatabaseBuilder config = new DatabaseConfig();
    config.setName(server().name());
    config.loadFromProperties(server().pluginApi().config().getProperties());
    config.setDataSource(server().dataSource());
    config.setReadOnlyDataSource(server().readOnlyDataSource());
    config.setDdlGenerate(false);
    config.setDdlRun(false);
    config.setRegister(false);
    config.setPackages(Collections.singletonList("misc.migration.v1_0"));

    Database tmpServer = DatabaseFactory.create(config);
    try {
      ETable table = new misc.migration.v1_0.ETable();
      table.setFrom("foo");
      table.setTo("bar");
      table.setIndex("id");
      table.setTextfield("test");
      tmpServer.save(table);
      table = tmpServer.find(ETable.class).where().eq("index", "id").findOne();
      assert table != null;
    } finally {
      tmpServer.shutdown(false, false);
    }
  }

  private void createHistoryEntities() {
    SqlUpdate update = server().sqlUpdate("insert into migtest_e_history (id, test_string) values (1, '42')");
    assertThat(server().execute(update)).isEqualTo(1);
    update = server().sqlUpdate("update migtest_e_history set test_string = '45' where id = 1");
    assertThat(server().execute(update)).isEqualTo(1);

    update = server().sqlUpdate("insert into migtest_e_history2 (id, test_string, obsolete_string1, obsolete_string2) values (1, 'foo', 'bar', null)");
    assertThat(server().execute(update)).isEqualTo(1);
    update = server().sqlUpdate("update migtest_e_history2 set test_string = 'baz' where id = 1");
    assertThat(server().execute(update)).isEqualTo(1);

    update = server().sqlUpdate("insert into migtest_e_history3 (id, test_string) values (1, '42')");
    assertThat(server().execute(update)).isEqualTo(1);
    update = server().sqlUpdate("update migtest_e_history3 set test_string = '45' where id = 1");
    assertThat(server().execute(update)).isEqualTo(1);

    update = server().sqlUpdate("insert into migtest_e_history4 (id, test_number) values (1, 42)");
    assertThat(server().execute(update)).isEqualTo(1);
    update = server().sqlUpdate("update migtest_e_history4 set test_number = 45 where id = 1");
    assertThat(server().execute(update)).isEqualTo(1);

    update = server().sqlUpdate("insert into migtest_e_history5 (id, test_number) values (1, 42)");
    assertThat(server().execute(update)).isEqualTo(1);
    update = server().sqlUpdate("update migtest_e_history5 set test_number = 45 where id = 1");
    assertThat(server().execute(update)).isEqualTo(1);

    update = server().sqlUpdate("insert into migtest_e_history6 (id, test_number1, test_number2) values (1, 2, 7)");
    assertThat(server().execute(update)).isEqualTo(1);
    update = server().sqlUpdate("update migtest_e_history6 set test_number2 = 45 where id = 1");
    assertThat(server().execute(update)).isEqualTo(1);
  }

  private void cleanup(String ... tables) {

    final boolean sqlServer = isSqlServer();
    final boolean postgres = isPostgres();

    StringBuilder sb = new StringBuilder();
    for (String table : tables) {
      // simple and stupid try to execute all commands on all dialects.
      if (sqlServer) {
        sb.append("alter table ").append(table).append(" set ( system_versioning = OFF  );\n");
        sb.append("alter table ").append(table).append(" drop system versioning;\n");
      }
      if (postgres) {
        sb.append("drop table ").append(table).append(" cascade;\n");
        sb.append("drop table ").append(table).append("_history cascade;\n");
      } else {
        sb.append("drop table ").append(table).append(";\n");
        sb.append("drop table ").append(table).append("_history;\n");
      }
      sb.append("drop view ").append(table).append("_with_history;\n");
      sb.append("drop sequence ").append(table).append("_seq;\n");
    }
    server().script().runScript("cleanup", sb.toString(), true);
    server().script().runScript("cleanup", sb.toString(), true);
  }

  private List<String> getTables() throws SQLException {
    List<String> ret = new ArrayList<>();
    try (Transaction txn = server().beginTransaction()) {
      ResultSet rs = txn.connection().getMetaData().getTables(null, null, null, null);
      while (rs.next()) {
        ret.add(rs.getString(3));
      }
    }
    return ret;
  }
}
